跳到主要内容

背包系统学习

背包的逻辑

  • 控制器负责刷新 UI 界面,但是它不去主动的修改数据,只接收数据层的通知
  • 数据层不去修改 UI 层,而是在修改了数据后通知控制器去刷新 UI(InventoryManager 控制器的 RefreshItem() 方法)

这个位于 UI 层与数据层之间的控制器就是 InventoryManager

ScriptableObjects 存储资源

如果需要创建一个专门用来存储资源的存储单元可以使用这个 ScriptableObjects

这个 ScriptableObjects 无需挂载在任何 Object 上,它就像编辑器脚本一样,是单独的

物品信息 Item

如下创建一个存储 Item 的资源(存储物品的形象,注意这里的 Equals 和 GetHashCode 要重写,原因看注释)

// 这个 fileName 是创建的资源默认名字
[CreateAssetMenu(fileName = "New Item", menuName = "Inventory/New Item")]
public class Item : ScriptableObject
{
public string itemName;
public Sprite itemImage; //渲染的精灵
public int itemHeld; //当前Item的数量
[TextArea] // 如果当描述信息很多的时候可以用这个属性,让它变成一个文本框(它还有其它的属性,最大行数,最小行数)
public string itemInfo;

public bool equip; // 当前物品是否可装备


/// <summary>
/// 因为用到了 List 的 Contains 函数,所以这里需要重写 Equals 和 GetHashCode(原理和 Java 一样)
/// </summary>
/// <param name="other"></param>
/// <returns></returns>
public override bool Equals(object other)
{
return other != null &&
(base.Equals(other) ||
((Item) other).itemName.Equals(this.itemName));
}

public override int GetHashCode()
{
return base.GetHashCode();
}
}

然后在资源菜单就可以看到这个创建资源的选项

这个创建的资源如下所示:

背包数据 Inventory

同理创建一个用于存储这个 Item 的背包,所谓的背包实际就是一个存储 Item 的数组

/// <summary>
/// 在菜单创建一个背包
/// </summary>
[CreateAssetMenu(fileName = "New Inventory", menuName = "Inventory/New Inventory")]
public class Inventory : ScriptableObject
{
public List<Item> itemList = new List<Item>();
}

因为这里一开始就生成了全部的格子,那也需要将背包也生成对应的那么多个,数组每一个位置对应背包的一个格子(空格子 Item 是空的就行了)

背包的结构

Grid 和 Slot 就是对应的 UI 界面

这个 Slot Grid 的 Grid Layout Group 组件会自动将格子排列

这个 Item Description 就是物品描述 Text

可以注意到上面的 Item有些显示,有些隐藏,隐藏的就是空格子,没隐藏的就是有物品的地方

注意这个格子的模板包含一个 Button 组件,当玩家点击了这个物品后(这里的 Button 点击事件会调用 Slot 脚本的 ItemOnClicked() 方法)使之能通知 InventoryManager 更新物品描述

UI 控制层 代码

首先创建一个 InventoryManager 类来全局控制 UI 界面的显示,将其随便挂载在一个地方(例如挂载在 Canvas 上面)

这里的 MyBag 就是上面存储背包内容的资源(ScriptableObjects),这里把数据传进来,为刷新背包提供数据

可以看到这个控制器内部的方法都是 static 修饰的,就是为了方便数据层调用它

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;

public class InventoryManager : MonoBehaviour
{
private static InventoryManager _instance;

public Inventory myBag;
public GameObject slotGrid; // 当前背包的背景(用来排序的那个父类)
public TextMeshProUGUI itemInfo;

public GameObject emptySlot; //空的预制件
public List<GameObject> slots = new List<GameObject>(); // 保存单元格

private void Awake()
{
if (_instance != null)
{
Destroy(this);
return;
}

_instance = this;
}

private void OnEnable()
{
// 开始前,先将物品栏里面的内容添加进背包 UI
RefreshItem();
_instance.itemInfo.text = ""; // 默认描述为空
}

/// <summary>
/// 更新 UI 界面里面的 Info 信息
/// </summary>
/// <param name="info"></param>
public static void UpdateItemInfo(string info)
{
_instance.itemInfo.text = info;
}

/// <summary>
/// 刷新背包的 UI 界面,这里其实就是将显示层和数据层分离
/// </summary>
public static void RefreshItem()
{
// 先清空
for (int i = 0; i < _instance.slotGrid.transform.childCount; i++)
{
// 删除 slotGrid 下面的子项目(注意:这里 GetChild(i) 取得的是 Transform,要删除的应该是 gameObject)
Destroy(_instance.slotGrid.transform.GetChild(i).gameObject);
// 别忘了清空这个 slots 数组,否则会一直累加下去
_instance.slots.Clear();
}

// 再遍历背包的内容,再添加同等数量的格子到 slotGrid下面
for (int i = 0; i < _instance.myBag.itemList.Count; i++)
{
_instance.slots.Add(Instantiate(_instance.emptySlot));
_instance.slots[i].transform.SetParent(_instance.slotGrid.transform);
// 然后给这个 Slot 赋值 Id
_instance.slots[i].GetComponent<Slot>().slotId = i;
_instance.slots[i].GetComponent<Slot>().SetupSlot(_instance.myBag.itemList[i]);
}
}
}

被拾取物

这个被拾取物需要添加一个碰撞盒

然后编写一个脚本用来标识,这个物品被拾取后应该添加到哪个背包里面,以及当前物品的信息

当物品被拾取后除了要添加到到背包里面(就是检查哪里有空格子),还需要通知 UI控制器当前数据已经被修改(这里的 InventoryManager.RefreshItem() 方法)

被拾取的物品,在拾取后将其存放在 Inventory(背包)的第一个空位

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ItemOnWorld : MonoBehaviour
{
public Item thisItem; // 当前物品的信息
public Inventory playerInventory; // 拾取这个物品会去到哪个背包(因为未来可能会有多个背包)

private void OnTriggerEnter2D(Collider2D other)
{
// 如果是玩家拾取的
if (other.gameObject.CompareTag("Player"))
{
AddNewItem();
Destroy(gameObject); // 拾取了物品后毁掉当前对象
}
}

/// <summary>
/// 如果拾取到东西,直接刷新背包(重新遍历),这样就避免了手动维护 UI 的 Add 操作
/// 使之只需让背包的 List 加一就行了
/// </summary>
private void AddNewItem()
{
// 如果背包里面没有这个物品,则将其添加进背包
if (!playerInventory.itemList.Contains(thisItem))
{
for (int i = 0; i < playerInventory.itemList.Count; i++)
{
// 找空位,把数据插入找到的第一个空位
if (playerInventory.itemList[i] == null)
{
playerInventory.itemList[i] = thisItem;
break;
}
}
}
else
{
// 如果已经有了则将其持有数量增加一个
thisItem.itemHeld += 1;
}
// 不管怎么样都刷新背包
InventoryManager.RefreshItem();
}
}

Slot 预制件的结构

Slot 预制件最终的样子

Slot 预制件的结构

给这个 Slot 设置一个 emptyItem 的 Tag

给这个 ItemImage 设置一个 fullItem 的 Tag

这里的思路就是如果这个 Slot 里面没有物品,则隐藏 Item 这个 UI,如果有物品则显示 Item,那为什么需要把 Tag 丢到 ItemImage 上面呢?因为 UI 渲染顺序是优先渲染下面的,所以射线也会优先碰到下面的 UI,所以应该把这个 Tag 放在 ItemImage

Slot 挂载的组件

将这个 Slot 的图片替换成格子的样子(这样就无需背景了)

Item 挂载的组件

需要给这个 Item 添加一个 Layout Element 组件,点击 Ignore Layout 使之屏蔽 Layout 规则,否则它会在提升到 Parent 的一瞬间受到上面的那个 Grid Layout Group 组件的影响使之自动排列,导致看起来会闪一下

注意这里的 Button 的 Transition 属性改成 Node 否则会警告当前 Button 没有 Image

ItemImage 挂载的组件

这个 ItemImage 就只是负责渲染物品 UI 的

Number 挂载的组件

Number 只是负责显示当前的物品数量,没什么好说

Slot 代码部分

using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class Slot : MonoBehaviour
{

public int slotId; // 创建一个变量标识这个 slot 的 ID
public Image slotImage;
public TextMeshProUGUI slotNum;
private string slotInfo;

// 当前 Slot 的子对象 Item
public GameObject itemInSlot;

/// <summary>
/// 当单击了 Item 会显示的它的描述
/// </summary>
public void ItemOnClicked()
{
InventoryManager.UpdateItemInfo(slotInfo);
}

/// <summary>
/// 将当前格子的 Item数据渲染到自己的格子里面
/// </summary>
/// <param name="item"></param>
public void SetupSlot(Item item)
{
// 如果当前格子是空的,当前 Item子对象不显示
if (item == null)
{
itemInSlot.SetActive(false);
return;
}
// 当前格子的 精灵设置为 ItemImage
slotImage.sprite= item.itemImage;
slotNum.text = item.itemHeld.ToString();
slotInfo = item.itemInfo;
}
}

Item 上的拖动代码

它的原理就是拖动物品后关闭当前被拖动物品的碰撞检测,使之能直接读取到被拖动物品的下一层,从而拿到下一层的物品进而判断

注意:这里使用到了上面设置的 Tag 来判断当前 UI 部分是否有物品

using System.Collections;
using System.Collections.Generic;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.EventSystems;

public class ItemOnDrag : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
// 拖拽开始时的父级
private Transform originalParent;
public Inventory myBag;
private int currentItemId; // 当前 ID

/// <summary>
/// 开始拖拽时
/// </summary>
/// <param name="eventData"></param>
public void OnBeginDrag(PointerEventData eventData)
{
originalParent = transform.parent;
currentItemId = originalParent.GetComponent<Slot>().slotId;

// 当前 UI 组件为鼠标的位置
transform.position = eventData.position;

// 因为 UI 默认的渲染顺序是从上往下渲染,所以下面的 UI 会挡住上面的 UI
// 这里将其丢到父级(会自动放在父级的最下面)从而使之不会被挡住
transform.SetParent(transform.parent.parent);

// 开始拖拽时关闭 UI 的射线检查
GetComponent<CanvasGroup>().blocksRaycasts = false;
}

/// <summary>
/// 正在拖拽
/// </summary>
/// <param name="eventData"></param>
public void OnDrag(PointerEventData eventData)
{
transform.position = eventData.position;
// 输出正在拖拽物品时正下方物品的名字
// Debug.Log(eventData.pointerCurrentRaycast.gameObject.name);
}

/// <summary>
/// 结束拖拽
/// </summary>
/// <param name="eventData"></param>
public void OnEndDrag(PointerEventData eventData)
{
// 结束拖拽时这个射线指向的对象
GameObject o = eventData.pointerCurrentRaycast.gameObject;

if (o == null) // 为空值时直接回归原位
{
transform.position = originalParent.position;
transform.SetParent(originalParent);
GetComponent<CanvasGroup>().blocksRaycasts = true;
return;
}

// 如果下面有 Item Image 则说明这个地方是一个格子
if (o.tag == "fullItem")
{
// 交换位置
// 注意是两层 parent
// 先将拖拽的物品位置以及父类换到新的地方
transform.position = o.transform.parent.position;
transform.SetParent(o.transform.parent.parent);

// itemList 的物品位置 ID 改变
var temp = myBag.itemList[currentItemId];
myBag.itemList[currentItemId] = myBag.itemList[o.GetComponentInParent<Slot>().slotId];
myBag.itemList[o.GetComponentInParent<Slot>().slotId] = temp;

// 再将另一个物品的位置以及父类换到原本拖拽的那个物品处
o.transform.parent.position = originalParent.position;
o.transform.parent.SetParent(originalParent);

// 结束拖拽时恢复 UI 的射线检查
GetComponent<CanvasGroup>().blocksRaycasts = true;
return;
}
// 如果是空格子则直接换位置,也无需把隐藏的 Item 换过来,因为,当拾取了物品后背包会自动刷新
else if (o.tag == "emptyItem")
{
transform.position = o.transform.position;
transform.SetParent(o.transform);

// 这个就无需取得父类,这里直接就能取得了
myBag.itemList[o.GetComponent<Slot>().slotId] = myBag.itemList[currentItemId];

// 避免自己放在自己位置的问题
if (o.GetComponent<Slot>().slotId != currentItemId)
{
myBag.itemList[currentItemId] = null;
}

// 结束拖拽时恢复 UI 的射线检查
GetComponent<CanvasGroup>().blocksRaycasts = true;
return;
}

// 如果没有物品则让被拖拽的对象回到原位
transform.position = originalParent.position;
transform.SetParent(originalParent);
GetComponent<CanvasGroup>().blocksRaycasts = true;
}
}

拖动背包

这个和上面的操作有点不同,它是加上鼠标的偏移量,且这里操作的是 RectTransform

public class MoveBag : MonoBehaviour, IDragHandler
{
private RectTransform currentRect;

private void Awake()
{
currentRect = GetComponent<RectTransform>();
}

public void OnDrag(PointerEventData eventData)
{
// 注意,Position 是它的中心点,如果直接修改中心点会导致移动的范围非常的广,所以这里应该修改它中心锚点的坐标
currentRect.anchoredPosition += eventData.delta; // 加上鼠标的偏移
}

}

注意:RectTransform 与 transform 的区别

存储和加载游戏

public class GameSaveManager : MonoBehaviour
{
public Inventory myInventory;

public void SaveGame()
{
String path = Application.persistentDataPath + "/game_SaveData/";

// 如果不存在则创建目录
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}

BinaryFormatter formatter = new BinaryFormatter();
FileStream file = File.Create(path + "inventory.alsritter");
var json = JsonUtility.ToJson(myInventory);
// 将二进制的数据
formatter.Serialize(file, json);
file.Close(); // 关闭流
}

public void LoadGame()
{
String path = Application.persistentDataPath + "/game_SaveData/";
BinaryFormatter formatter = new BinaryFormatter();

if (File.Exists(path + "inventory.alsritter"))
{
FileStream file = File.Open(path + "inventory.alsritter", FileMode.Open);
JsonUtility.FromJsonOverwrite((string) formatter.Deserialize(file), myInventory);
}
}
}

如果想要转换为 JSON 则使用下面这个,但是反序列化可能会失败,所以一般还是上面的保险

public Inventory myInventory;
public void SaveGame()
{
String path = Application.persistentDataPath + "/game_SaveData/";
// 如果不存在则创建目录
if (!Directory.Exists(path))
{
Directory.CreateDirectory(path);
}
String json = JsonUtility.ToJson(myInventory);
Debug.Log(json);

StreamWriter sw = new StreamWriter(path + "inventory.json");
sw.Write(json);
sw.Close(); // 关闭流
}